import { createProps, setProperty } from '../../js/createProps.js' import breakpoint from '../../js/breakpoints.js' import { makeId, setAttributes, debounce, setCss } from '../../js/utils.js' import { isSafari } from '../../js/userAgents.js' // import { getOppositeContrast } from '../../js/contrasts.js' // import dispatchEvent from '../../js/events.js' import { BROWSER_CONTEXT, CONTRAST_LIGHT } from '../../js/constants.js' class Slider extends HTMLElement { constructor() { super() this.classList.add('u-slider') // Default options this.#setDefaultOptions() // Events this.boundPrevClick = this.#onPrevClick.bind(this) this.boundNextClick = this.#onNextClick.bind(this) this.boundNextClick = this.#onNextClick.bind(this) this.boundNavClick = this.#onNavClick.bind(this) this.boundScroll = this.#onScroll.bind(this) // this.boundStartDragAndDrop = this.#onStartDragAndDrop.bind(this) // this.boundStopDragAndDrop = this.#onStopDragAndDrop.bind(this) // this.boundDragAndDrop = this.#onDragAndDrop.bind(this) this.boundOnResize = debounce(this.#onResize.bind(this), 250) } #setDefaultOptions() { // this.contrast = CONTRAST_LIGHT -> todo: we need to manage contrast differently, may be not needed anymore this.defaultPosition = 0 this.displayButtons = true this.displayNav = true this.displayCounter = false this.displayGradients = false this.itemsPerSlide = 1 this.overflow = false this.overflowOpacity = 0.25 this.flexAuto = false this.justify = null } connectedCallback() { // We need an id for event dispatcher if (!this.id || this.id === 'undefined') { this.id = `slider_${makeId(5)}` } } static get observedAttributes() { return ['data'] } attributeChangedCallback(name) { if (name === 'data') { const temp = this.getAttribute('data') if (temp !== '') { this.#loadData() } } if (name === 'slot') { this.initSlider() } } #loadData() { createProps(this, true) this.#render() this.#setProperties() this.initSlider() } #setProperty(key, value) { setProperty(this, key, value) } #setProperties() { const { options, a11y_labels } = this.data // Create refs to html elements this.container = this.querySelector('.slider-container') this.wrapper = this.querySelector('.slider-wrapper') this.carousel = this.querySelector('.slider-carousel') // Set slider properties this.currentIndex = 0 this.a11y_labels = a11y_labels // Get right options depending on breakpoint if (breakpoint.is.desktop) { this.options = options.desktop } else if (breakpoint.is.tablet) { this.options = options.tablet } else { this.options = options.mobile } // Set options for (const property in this.options) { this.#setProperty(property, this.options[property]) } // If counter is displayed we force buttons and pagination to false if (this.displayCounter) { this.displayNav = this.displayButtons = false } // Overflow needs a class if (this.overflow) { this.container.classList.add('overflow') } else { this.container.classList.remove('overflow') } // Contrast needs an attribute // todo: we need to manage contrast diffrently, may be not needed anymore // this.buttonsContrast = getOppositeContrast(this.contrast) // this.setAttribute('contrast', this.contrast) // Set CSS properties for non dynamic options this.#setCssSlideMinHeight() this.#setCssSliderGap() this.#setCssPlaceholder() } /** Public Method **/ initSlider() { // Get an updated number of items (if async we run again) this.slidesElements = this.querySelectorAll('.slider-carousel > li:not(.safari-fix)') this.itemNumber = this.slidesElements.length if (this.itemNumber <= 0) { // If there is no elements, do not initialize return; } // For asynchronous initSlider() we need to reset first itemWidth and itemPerSlide to get the original values before other calculations this.#setProperty('item_width', this.options['item_width']) this.#setProperty('items_per_slide', this.options['items_per_slide']) // If itemsPerSlide is 'auto' we need to calculate it's real value based on itemWidth or scrollWidth / itemNumber // We also need to set the CSS --item-width variable to handle the calculation correctly if (this.itemsPerSlide === 'auto') { this.#calculateItemsPerSlide() } this.#setCssSlideWidth() this.#setCssItemsPerSlide() this.#setCssFlexAuto() // Other properties that can be set only after the rest this.maxSlides = this.itemNumber - this.itemsPerSlide // Finish rendering this.#renderButtons() this.#renderNav() this.#renderCounter() this.#renderGradients() // Init methods in this order ! this.#applyCenteredLayout() this.#getSliderPositions() this.#getOpacityParams() this.#setOpacity() this.#updateStates() this.#bindEvent() this.#gotoDefaultPosition() // Render this safariFix at the end because we don't want the extra li to be considered in sliderElements // this.#renderSafariFix() } #render() { this.innerHTML = `
` } #renderButtons() { // Important: if maxSlides <= 0 it means we don't have scroll, so we don't need buttons const hasNoButton = !this.displayButtons || this.maxSlides <= 0 if (hasNoButton) { // Needed if we reinit if (this.prevButton && this.nextButton) { this.prevButton.remove() this.nextButton.remove() } return } // Check if button exists already (for async content in order to avoid creating two occurrences) if (!this.prevButton) { this.prevButton = this.#renderButton('slider-prev') } if (!this.nextButton) { this.nextButton = this.#renderButton('slider-next') } } #renderButton(position, renderInCounter = false) { // const buttonContrast = renderInCounter ? this.contrast : this.buttonsContrast const isPrev = position === 'slider-prev' const label = isPrev ? 'prev' : 'next' const CustomElement = window.customElements.get("u-navbtn") const buttonEl = new CustomElement() buttonEl.classList.add(position) setAttributes(buttonEl, { variant: 'primary', // contrast: buttonContrast, size: 'md', icon_right: isPrev ? '24/ui/chevron-left' : '24/ui/chevron-right', aa_label: this.a11y_labels[label] }) if (!renderInCounter) { isPrev ? this.container.prepend(buttonEl) : this.container.append(buttonEl) } else { isPrev ? this.counter.prepend(buttonEl) : this.counter.append(buttonEl) } return buttonEl } #renderNav() { if (!this.displayNav || this.maxSlides === 0) { // Needed if we reinit if (this.nav) { this.nav.remove() } return } // Check if nav exists already (for async content in order to avoid creating two occurrences) if (this.nav) { this.nav.remove() } const nav = document.createElement('nav') let bullets = '' for (let i = 0; i <= this.maxSlides; i++) { bullets += this.#renderBullet(i) } nav.innerHTML = `${bullets}` this.appendChild(nav) this.nav = this.querySelector('nav') this.navElements = this.querySelectorAll('nav button') } #renderBullet(index) { return `` } #renderCounter() { if (!this.displayCounter || this.maxSlides === 0) { // Needed if we reinit if (this.counter) { this.counter.remove() } return } // Check if counter exists already (for async content in order to avoid creating two occurrences) if (this.counter) { this.counter.remove() } this.counter = document.createElement('div') this.counter.classList.add('slider-counter') this.counter.classList.add('t-sm-regular') this.counter.setAttribute('aria-hidden', 'true') this.counter.innerHTML = `${ this.currentIndex + 1 }/${this.maxSlides + 1}` this.prevButton = this.#renderButton('slider-prev', true) this.nextButton = this.#renderButton('slider-next', true) this.appendChild(this.counter) } #renderGradients() { if (!this.displayGradients || this.overflow) { return } this.#setCss('--gradients-width', this.gradientsWidth, true) this.#setCss('--gradients-color', this.gradientsColor) this.#setCss('--gradients-color-transparent', this.gradientsColor + '00') this.container.classList.add('hasGradients') } #renderSafariFix() { // In safari, both Desktop and iOS, the scroll doesn't reach the end, because the scroll-padding on the right side seems to not been taken into account // Same bug is happening in Chrome on iOS (seems to use Safari webkit rendering) // The fix is to render an extra
  • with the size equal to the scroll-padding if (!this.overflow || !isSafari()) { return } // Check if safariFix exists already (for async content in order to avoid creating two occurences) if (!this.safariFix) { this.safariFix = document.createElement('li') this.safariFix.classList.add('safari-fix') this.safariFix.setAttribute('aria-hidden', true) this.carousel.appendChild(this.safariFix) } const safariFixWidth = (this.paddingCorrection - this.sliderGap) / BROWSER_CONTEXT this.safariFix.style.setProperty('width', safariFixWidth + 'rem') } #calculateItemsPerSlide() { // For asynchronous slider content, if initSlider() is called twice, we need to remove first this class this.carousel.classList.remove('no-slides') let itemWidth = this.itemWidth // If itemWidth is not provided or 'auto', we need to calculate an average width for items that doesn't have fixed width if (this.itemWidth === undefined || this.itemWidth === 'auto') { itemWidth = (this.carousel.scrollWidth - this.sliderGap * (this.itemNumber - 1)) / this.itemNumber } this.itemsPerSlide = Math.ceil(this.carousel.offsetWidth / itemWidth) } #getSliderPositions() { this.sliderPositions = [] this.maxScroll = this.carousel.scrollWidth - this.carousel.offsetWidth // We need to manage the first item initial position in case the slides are visible on each side (equivalent to scroll-padding CSS of ul.carousel) const firstItemOffsetLeft = this.slidesElements[0].offsetLeft this.querySelectorAll('li').forEach(element => { this.sliderPositions.push(element.offsetLeft - firstItemOffsetLeft) }) } #setCss(name, value, isRem = false) { setCss(this, name, value, isRem) } #setCssSlideWidth() { if (this.itemWidth === 'auto') { const itemWidthAuto = (this.container.clientWidth - (this.itemsPerSlide - 1) * this.sliderGap) / this.itemsPerSlide this.#setCss('--item-width', itemWidthAuto, true) } else if (this.itemWidth) { this.#setCss('--item-width', this.itemWidth, true) } } #setCssSlideMinHeight() { if (this.slideMinHeight === 'auto') { this.#setCss('--slide-min-height', 'auto') } else if (this.slideMinHeight !== undefined) { this.#setCss('--slide-min-height', this.slideMinHeight, true) } } #setCssSliderGap() { if (this.sliderGap || this.sliderGap === 0) { this.#setCss('--slider-gap', this.sliderGap.toString(), true) } } #setCssFlexAuto() { if (this.flexAuto) { this.carousel.classList.add('flex-auto') } } #setCssNoSlidesJustify() { this.#setCss('--no-slides-justify', this.justify) } #setCssPlaceholder() { this.#setCss('--placeholder-background', this.placeholderBackground) } #setCssItemsPerSlide() { this.#setCss('--items-per-slide', this.itemsPerSlide) } #applyCenteredLayout() { if (this.maxSlides > 0) { return } // If maxSlides is 0 or negative, it means that we have less items than needed to fulfill the carousel width // So to center the items (for example the bubbles when only 3) we need to apply a justify-content: --no-slides-justify fix this.carousel.classList.add('no-slides') this.#setCssNoSlidesJustify() } /** Public Method **/ updateSlider(index, instantly = false) { this.#getSliderPositions() this.goToSlide(parseInt(index), instantly) } #updateStates() { this.#setActiveButtons() this.#setActiveBullet() this.#setCounter() this.#setActiveSlides() } #setActiveButtons() { if ((!this.displayButtons || this.maxSlides <= 0) && !this.displayCounter) { return } // First we remove all hide classes (because with pagination we can move from more than 2 slides) this.prevButton.classList.remove('isHidden') this.nextButton.classList.remove('isHidden') // Then we reset hide class on the right button if (this.currentIndex === 0) { this.prevButton.classList.add('isHidden') } else if (this.currentIndex === this.maxSlides) { this.nextButton.classList.add('isHidden') } } #setActiveBullet() { if (!this.displayNav || this.maxSlides <= 0) { return } this.navElements.forEach(item => { item.classList.remove('active') }) this.navElements[this.currentIndex].classList.add('active') } #setCounter() { if (!this.displayCounter || this.maxSlides === 0) { return } this.counter.querySelector('.current').textContent = this.currentIndex + 1 } #setActiveSlides() { // If we have no items overflowing on each side, we don't need to manage inactive slides if (!this.overflow) { return } this.slidesElements.forEach(element => { element.classList.add('inactive') }) let tempIndex = this.currentIndex for (let i = 0; i < this.itemsPerSlide; i++) { if (this.slidesElements[tempIndex + i]) { this.slidesElements[tempIndex + i].classList.remove('inactive') } } } /** Public Method **/ goToSlide(index, instantly = false) { if (index !== undefined) { this.currentIndex = index } // If index is over maxSlides, it means we reached out the end of scroll, so we don't need to update states anymore if (index < this.maxSlides) { this.#updateStates() } // We scroll to the right position (scrollTo duration is approx 750ms) this.carousel.scrollTo({ left: this.sliderPositions[this.currentIndex], behavior: instantly ? 'auto' : 'smooth' }) } #gotoDefaultPosition() { if (this.defaultPosition === 0 || this.defaultPosition > this.maxSlides + 1) { return } this.goToSlide(this.defaultPosition, true) } #onScroll() { // Clear our timeout throughout the scroll if (this.isScrolling) { clearTimeout(this.isScrolling) } this.#setOpacity() // Set a timeout to run after scrolling ends this.isScrolling = setTimeout(() => { const carouselScrollLeft = Math.round(this.carousel.scrollLeft) // Ideal use case (exact pixel value) let index = this.sliderPositions.indexOf(carouselScrollLeft) // Sometimes the scrollLeft value can be +1 ou -1 pixels, so if not found, we need to search for +1 or -1 if (index === -1) { index = this.sliderPositions.indexOf(carouselScrollLeft - 1) } if (index === -1) { index = this.sliderPositions.indexOf(carouselScrollLeft + 1) } // For items with flexible width (flex_auto) the last position of the scroll can not match an exact SliderPositions if (carouselScrollLeft === this.maxScroll) { index = this.maxSlides } if (index >= 0) { this.#switchBullets(index, true) } }, 100) } #getOpacityParams() { if (!this.overflow) { return } // Set properties used for slide opacity calculation in #setOpacity() this.paddingCorrection = parseInt( getComputedStyle(this.carousel).getPropertyValue('padding-left') ) this.itemWidthAdjusted = this.slidesElements[0].offsetWidth + this.sliderGap } #setOpacity() { if (!this.overflow) { return } const scrollX = this.carousel.scrollLeft // if (DEBUG) this.debugScrollX.innerText = scrollX this.slidesElements.forEach((item, index) => { // Adjust item offsetLeft because of slider padding on each side const itemOffsetLeft = item.offsetLeft - this.paddingCorrection // Adjust item offsetLeft to add itemWidth + sliderGap let itemX = itemOffsetLeft + this.itemWidthAdjusted // Determine if we need to calculate left or right items let percentage = 100 if (scrollX > itemX) { // Avoid calculation if item is outside on left // if (DEBUG) direction = 'outside left' percentage = 0 } else if (scrollX >= this.itemWidthAdjusted * index) { // Do left calculations // if (DEBUG) direction = 'left' percentage = Math.round(((itemX - scrollX) / this.itemWidthAdjusted) * 100) } else if (scrollX < itemX - (1 + this.itemsPerSlide) * this.itemWidthAdjusted) { // Avoid calculation if item is outside on right // if (DEBUG) direction = 'outside right' percentage = 0 } else if (scrollX <= itemX - this.itemsPerSlide * this.itemWidthAdjusted) { // Do right calculations // if (DEBUG) direction = 'right' percentage = Math.round( (this.itemsPerSlide + 1) * 100 - ((itemX - scrollX) / this.itemWidthAdjusted) * 100 ) } const opacityPercentage = percentage / 100 let finalOpacity = Math.round( (opacityPercentage * (1 - this.overflowOpacity) + this.overflowOpacity) * 100 ) / 100 // if (DEBUG) item.positionDebug.innerText = direction // if (DEBUG) item.opacityDebug.innerText = finalOpacity item.firstElementChild.style.setProperty('opacity', finalOpacity) }) } #switchBullets(index, alreadyScrolled = false) { if (!this.slidesElements[index]) { return } this.currentIndex = index // only fires goToSlide if user clicks on pagination buttons if (!alreadyScrolled) { this.goToSlide() } else { this.#updateStates() } } #onPrevClick() { if (this.currentIndex > 0) { this.currentIndex-- } this.goToSlide() } #onNextClick() { if (this.currentIndex < this.maxSlides) { this.currentIndex++ } this.goToSlide() } #onNavClick(e) { this.currentIndex = parseInt(e.target.dataset.item) this.#setActiveBullet() this.#switchBullets(this.currentIndex) } // #dispatchReady() { // dispatchEvent({ // eventName: EVENT_SLIDER_READY, // args: { id: this.id }, // element: this // }) // } #onResize() { this.#setDefaultOptions() this.#setProperties() this.initSlider() } // #onStartDragAndDrop(e) { // console.log('start drag') // this.carousel.style.scrollSnapType = 'initial' // this.carousel.addEventListener('mousemove', this.boundDragAndDrop) // this.startDragPosition = { // // The current scroll // left: this.carousel.scrollLeft, // // Get the current mouse position // x: e.clientX // } // console.log('startDragPosition', this.startDragPosition.left, this.startDragPosition.x) // } // // #onStopDragAndDrop() { // console.log('stop drag') // this.carousel.removeEventListener('mousemove', this.boundDragAndDrop) // this.carousel.style.scrollSnapType = 'x mandatory' // } // // #onDragAndDrop(e) { // // How far the mouse has been moved // const dx = e.clientX - this.startDragPosition.x; // console.log('dx', dx) // // Scroll the element // this.carousel.scrollLeft = this.startDragPosition.left - dx; // console.log('scrollLeft', this.startDragPosition.left - dx, this.carousel.scrollLeft) // } #bindEvent() { this.#unbindEvent() this.carousel.addEventListener('scroll', this.boundScroll) // this.carousel.addEventListener('mousedown', this.boundStartDragAndDrop) // this.carousel.addEventListener('mouseup', this.boundStopDragAndDrop) if (this.prevButton) { this.prevButton.addEventListener('click', this.boundPrevClick) } if (this.nextButton) { this.nextButton.addEventListener('click', this.boundNextClick) } if (this.navElements) { this.navElements.forEach(element => { element.addEventListener('click', this.boundNavClick) }) } window.addEventListener('resize', this.boundOnResize) } #unbindEvent() { this.carousel.removeEventListener('scroll', this.boundScroll) if (this.prevButton) { this.prevButton.removeEventListener('click', this.boundPrevClick) } if (this.nextButton) { this.nextButton.removeEventListener('click', this.boundNextClick) } if (this.navElements) { this.navElements.forEach(element => { element.removeEventListener('click', this.boundNavClick) }) } window.removeEventListener('resize', this.boundOnResize) } disconnectedCallback() { this.#unbindEvent() } } customElements.get('u-slider') || customElements.define('u-slider', Slider) export default Slider